因為匯入的模組和函數甚多,為了避免一個一個函數匯入過於冗長且易於撞名需另外改名,所以採取整個模組匯入的模式。
import * as O from 'fp-ts/Option';
import * as IO from 'fp-ts/IO';
import * as T from 'fp-ts/Task';
import * as TE from 'fp-ts/TaskEither';
import * as R from 'fp-ts/Reader';
import { pipe, flow } from 'fp-ts/function';
import { log, error } from 'fp-ts/Console';
為了模擬非同步執行,置入之前寫過的delay函數。
type Delay = (ms: number) => Task<void>;
export const delay: Delay = (ms) => async () =>
new Promise((resolve) => setTimeout(resolve, ms));
接著定義User、Data和Deps三個型別,Deps型別是作為Reader的依賴注入型別,我們用interface來定義,以作為區別。為了增加練習時,型別的豐富性,Deps型別模擬了有同步、非同步的工作,錯誤處理上也有Option的型別。db和auth屬性模擬非同步函數操作;templateRenderer和logger屬性則模擬同步操作;config屬性則是靜態資料型別。
type User = {
id: number;
username: string;
email: string;
}
type Data = {
appName: string;
user: User;
isPremium: boolean;
}
interface Deps {
// 資料庫查詢 - 使用安全型別 T.Task<O.Option<User>> 建構輸出,可以處理無法查詢的處理
db: {
getUserById: (id: number) => T.Task<O.Option<User>>;
};
// 權限資格服務 - 使用 T.Task型別處理非同步操作
auth: {
userHasPermission: (userId: number, permission: string) => T.Task<boolean>;
};
// 模板渲染器 - 使用 IO.IO 同步操作型別建構函數輸出
templateRenderer: {
render: (templateName: string, data: Data) => IO.IO<string>;
};
// 日誌參生器 - 也是一個同步操作的IO.IO型別
logger: {
info: (message: string) => IO.IO<void>;
error: (message: string) => IO.IO<void>;
};
// 靜態應用配置 - 純數據,不需要嵌套任何型別建構子
config: {
appName: string;
features: {
isPremiumEnabled: boolean;
};
};
}
接下來建立getUser函數,它的型別簽名是
type GetUser = (userId: number) => R.Reader<Deps, TE.TaskEither<Error, User>>;
const getUser: GetUser = (userId: number) =>
({ db: { getUserById }, logger: { info } }) =>
pipe(
getUserById(userId), // T.Task<O.Option<User>>
T.flatMap(TE.fromOption(() => Error(`User ${userId} not found`))), // TE.TaskEither<Error,O.Option<User>>
TE.tapIO(() => info(`Fetching user ${userId}`)) // TE.TaskEither<E, User>
);
程式解說:
程式的另一種寫法為:
const getUser: GetUser = (userId: number) =>
pipe(
R.ask<Deps>(), // R.Reader<Deps, Deps>
R.map(({ db: { getUserById }, logger: { info, error } }) =>
pipe(
getUserById(userId), // T.Task<O.Option<User>>
T.flatMap(TE.fromOption(() => Error(`User ${userId} not found`))), // TE.TaskEither<Error, O.Option<User>>
TE.tapIO(() => info(`Fetching user ${userId}...`)) // TE.TaskEither<E, User>
) // R.map(Deps => TE.TaskEither<E, User>) 等同 R.Reader<Deps, Deps> => R.Reader<Deps, TE.TaskEither<E, User>>
)
);
接下來的權限檢查函數checkPermission和模板渲染函數renderProfileTemplate,它們的邏輯也是類似,型別簽名和實作分別如下:
type CheckPermission = (
userId: number,
permission: string
) => R.Reader<Deps, TE.TaskEither<Error, boolean>>;
const checkPermission: CheckPermission =
(userId, permission) =>
({ auth: { userHasPermission }, logger: { info } }) =>
pipe(
userHasPermission(userId, permission), //
TE.fromTask,
TE.mapError((error) => new Error(`Permission check failed: ${error}`)),
TE.tapIO(() =>
info(`Checking permission '${permission}' for user ${userId}`)
)
);
type RenderProfileTemplate = (user: User) => R.Reader<Deps, IO.IO<string>>;
const renderProfileTemplate: RenderProfileTemplate =
(user: User) =>
({
templateRenderer: { render },
config: {
appName,
features: { isPremiumEnabled },
},
logger: { info },
}) =>
pipe(
render('user-profile', {
user,
appName: appName,
isPremium: isPremiumEnabled,
}),
IO.tap((t) =>
info(`Rendering profile for user ${user.username}, template: ${t}`)
)
);
和getUser不同的是要從Task型別轉換到TaskEither型別,所以使用TE.mapError函數,並給予錯誤訊息。
至於主程式getUserProfile的實作則使用Do notation來綁定user和canView兩個值,完整的程式碼和型別簽名如下:
type GetUserProfile = (
userId: number
) => R.Reader<Deps, TE.TaskEither<Error, User>>;
const getUserProfile: GetUserProfile = (userId) => (deps: Deps) =>
pipe(
TE.Do, // T.TaskEither<{}>
TE.bind('user', () => getUser(userId)(deps)), // T.TaskEither<{user: User}>
TE.bind('canView', () => checkPermission(userId, 'VIEW_PROFILE')(deps)), // T.TaskEither<{user: User, canView: boolean}>
TE.flatMap(({ user, canView }) =>
canView
? TE.right(user)
: TE.left(new Error('User does not have permission to view profile'))
),
TE.tapIO((user) => renderProfileTemplate(user)(deps))
);
最後實作介面Deps的實例realDependencies:
const realDependencies: Deps = {
db: {
getUserById: (id) => async () => {
console.log(`[DB] Querying user ${id}`);
await delay(1000)();
if (id === 1)
return O.some({
id: 1,
username: 'johndoe',
email: 'john@example.com',
});
return O.none;
},
},
auth: {
userHasPermission: (userId, permission) => async () => {
console.log(`[AUTH] Checking ${permission} for user ${userId}`);
await delay(500)();
return userId === 1;
},
},
templateRenderer: {
render: (templateName, data) => () => {
console.log(`[TEMPLATE] Rendering ${templateName} with data:`, data);
return `
<html>
<head><title>${data.appName} - Profile</title></head>
<body>
<h1>Welcome, ${data.user.username}!</h1>
<p>Email: ${data.user.email}</p>
${data.isPremium ? '<p>Premium Member!</p>' : ''}
</body>
</html>
`;
},
},
logger: {
info: (msg) => log(`[INFO] ${new Date().toISOString()}: ${msg}`),
error: (msg) => error(`[ERROR] ${new Date().toISOString()}: ${msg}`),
},
config: {
appName: 'My Awesome App',
features: {
isPremiumEnabled: true,
},
},
};
以上是純函數的部分,接下來我們要讓主程式注入Deps的實例realDependencies並執行它。
const getUserProgram = (n: number) =>
pipe(
getUserProfile(n),
R.map(
TE.match(
(e) => console.log(`Error:${e.message}`),
(user) => console.log('Success!')
)
)
);
getUserProgram(1)(realDependencies)();
使用函數式設計時最重要的事就注意型別的推論,為了函數能順利合成接管,型別之間適時轉換也很重要。今天的範例包含了同步、非同步工作和IO工作,錯誤處理則有Option和Either之間的轉換,也練習了Reader的使用。今日的內容分享到此,明天再見。